Empowering Trauma and EMS QI Processes

{traumar} and {nemsqar}

Nicolas Foss, Ed.D., MS

2025-08-10

QI Matters to Trauma and EMS Programs

  • EMS and trauma systems critically depend on timely, accurate performance data
  • Most U.S. jurisdictions require reporting to a centralized trauma registry and EMS registry
  • Each hospital and EMS service have access to their own raw data via their Electronic Health Record (EHR)
  • Hospitals and EMS services often lack infrastructure for data science and/or staff
  • EHRs and National repositories for EMS/trauma data provide limited supports for performance calculation

Overview: National Context

  • National Trauma Data Bank: trauma registry data, hospital-based
  • National EMS Information System: national EMS data, prehospital events
  • NTDB and NEMSIS: Consistent element names for respective registry types, standardized formats
  • National EMS Quality Alliance: National EMS Quality Alliance, expert EMS stakeholders, published 21 EMS service quality measures
  • SEQIC: System Evaluation and Quality Improvement Committee, Iowa-based, designed 13 trauma system quality measures

Introducing traumar

  • Implements metrics driven the by academic literature
    • Risk-adjusted mortality metrics
      • W, M, and Z scores (Based on the Major Trauma Outcomes Study)
      • Relative mortality metric (RMM) from Napoli et a. (2017)
  • Built to calculate SEQIC indicators
  • Friendly to NTDB data formats and others

traumar: Examples!

Here, we will explore traumar::seqic_indicator_1()

Example Trauma Data

See the GitHub repo for this presentation for how the data were generated.

# Check out the data
dplyr::glimpse(valid_data)
Rows: 500
Columns: 10
$ incident_id      <int> 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16…
$ activation_level <fct> Level 2, None, Level 1, Level 1, Level 1, Level 1, No…
$ provider_type    <fct> Surgery/Trauma, Surgery/Trauma, Emergency Medicine, S…
$ trauma_level     <fct> II, II, I, IV, II, I, I, III, III, IV, I, IV, II, III…
$ response_minutes <dbl> 8.4400988, -0.3779051, 12.8394157, 4.2261894, 7.02231…
$ provider         <chr> "Dr. D", "Dr. A", "Dr. D", "Dr. B", "Dr. B", "Dr. B",…
$ Ps               <dbl> 0.710253388, 0.118368868, 0.001123374, 0.400708835, 0…
$ survival         <int> 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1,…
$ groups           <chr> "B", "A", "D", "G", "D", "A", "A", "E", "F", "E", "G"…
$ death            <dbl> 0, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 0, 1, 1, 1, 0, 1, 0,…

Run the SEQIC Indicator 1 Function

Group the output by verification level and calculate 95% confidence intervals

indicator_1 <- valid_data |> 
  traumar::seqic_indicator_1(
  trauma_team_activation_level = activation_level,
  trauma_team_physician_service_type = provider_type,
  level = trauma_level,
  included_levels = c("I", "II", "III", "IV"),
  unique_incident_id = incident_id,
  response_time = response_minutes,
  trauma_team_activation_provider = provider,
  groups = "trauma_level",
  calculate_ci = "wilson"
) |> 
  dplyr::select(trauma_level, tidyselect::matches("(?:seqic_|ci_)1[ad]"))

Results

The output 1!

indicator_1 |> 
  dplyr::mutate(
    dplyr::across(-1, 
                  ~ ifelse(!is.na(.), 
      traumar::pretty_percent(variable = ., n_decimal = 2), NA_real_)))
# A tibble: 4 × 7
  trauma_level seqic_1a lower_ci_1a upper_ci_1a seqic_1d lower_ci_1d upper_ci_1d
  <fct>        <chr>    <chr>       <chr>       <chr>    <chr>       <chr>      
1 I            92.65%   82.98%      97.26%      25%      18.15%      33.29%     
2 II           94.83%   84.7%       98.65%      23.88%   17.13%      32.16%     
3 III          <NA>     <NA>        <NA>        21.98%   14.25%      32.12%     
4 IV           <NA>     <NA>        <NA>        27.17%   18.67%      37.62%     

traumar: Examples!

Here, we will explore traumar::rm_bin_summary()

Run traumar::rm_bin_summary()

No grouping and calculate 95% confidence intervals

rm_output <- valid_data |> 
  traumar::rm_bin_summary(Ps_col = Ps,
                          outcome_col = survival,
                          group_vars = NULL,
                          n_samples = 100,
                          Divisor1 = 3,
                          Divisor2 = 3,
                          Threshold_1 = 0.8,
                          Threshold_2 = 0.9,
                          seed = 10232015
                          ) |> 
  dplyr::rename(RMM = population_RMM) |> 
  dplyr::relocate(RMM, .before = midpoint)

traumar::rm_bin_summary() Results!

rm_output
# A tibble: 7 × 19
  bin_number  TA_b  TD_b   N_b   EM_b AntiS_b AntiM_b bin_start bin_end      RMM
       <int> <int> <int> <int>  <dbl>   <dbl>   <dbl>     <dbl>   <dbl>    <dbl>
1          1     1   136   137 0.993   0.0109  0.989   0.000221  0.0356 -0.00367
2          2    23   113   136 0.831   0.118   0.882   0.0356    0.253   0.0578 
3          3    75    61   136 0.449   0.507   0.493   0.253     0.805   0.0909 
4          4    10     2    12 0.167   0.818   0.182   0.805     0.826   0.0835 
5          5    11     1    12 0.0833  0.843   0.157   0.826     0.861   0.468  
6          6    11     1    12 0.0833  0.885   0.115   0.861     0.906   0.274  
7          7    52     3    55 0.0545  0.957   0.0433  0.906     0.993  -0.259  
# ℹ 9 more variables: midpoint <dbl>, R_b <dbl>, population_RMM_LL <dbl>,
#   population_RMM_UL <dbl>, population_CI <dbl>, bootstrap_RMM_LL <dbl>,
#   bootstrap_RMM <dbl>, bootstrap_RMM_UL <dbl>, bootstrap_CI <dbl>

nemsqar: EMS QI with NEMSIS

  • Built on NEMSQA performance measures
  • Follows the NEMSIS data standard
    • Will work a data source as a single data.frame containing all needed elements, or multiple ‘table’ arguments
    • Using the table arguments is preferred as NEMSIS tables are typically set up using the star schema format on a SQL server of some kind
  • Scales to full state-level EMS datasets

Let’s Explore nemsqar

A look at some EMS-like data

ems_data |> dplyr::glimpse()
Rows: 500
Columns: 10
$ erecord_01      <chr> "R001", "R002", "R003", "R004", "R005", "R006", "R007"…
$ patient_dob     <date> 2018-12-13, 1995-12-13, 1991-07-01, 1954-01-30, 2020-…
$ incident_date   <date> 2025-01-10, 2025-05-04, 2025-03-08, 2025-04-11, 2025-…
$ epatient_15     <int> 6, 29, 33, 71, 4, 63, 49, 58, 75, 35, 28, 30, 71, 40, …
$ epatient_16     <chr> "Years", "Years", "Years", "Years", "Years", "Years", …
$ eresponse_05    <dbl> 2205009, 2205009, 2205001, 2205009, 2301001, 2205003, …
$ emedications_03 <chr> "Contraindication Noted", "Nitroglycerin", "Nitroglyce…
$ eprocedures_03  <chr> "Unable to Complete", "CPAP", "Oxygen Therapy", "Refus…
$ evitals_12      <int> 74, 85, 81, 78, 71, 78, 97, 70, 94, 84, 94, 94, 67, 64…
$ county          <chr> "B", "C", "A", "C", "A", "C", "C", "A", "B", "C", "C",…

nemsqar: Two Kinds of Fun

  • *_population() functions define eligible cases (e.g. respiratory_02_population())
    • Return a list of tibbles with the columns passed to the function plus new columns used in calculations
  • Wrapper functions implement full NEMSQA measures (e.g. respiratory_02())

Explore the Respiratory-02 Population Function

This function will gather the records we need to calculate performance on Respiratory-02:

# Get the list object as output
resp_out <- ems_data |> 
  nemsqar::respiratory_02_population(erecord_01_col = erecord_01,
                                     incident_date_col = incident_date,
                                     patient_DOB_col = patient_dob,
                                     epatient_15_col = epatient_15,
                                     epatient_16_col = epatient_16,
                                     eresponse_05_col = eresponse_05,
                                     evitals_12_col = evitals_12,
                                     emedications_03_col = emedications_03,
                                     eprocedures_03_col = eprocedures_03
                                     )

List Output of a Population Function

# Examine resp_out's contents
summary(resp_out)
                     Length Class  Mode
filter_process        2     tbl_df list
adults               22     tbl_df list
peds                 22     tbl_df list
initial_population   22     tbl_df list
computing_population 22     tbl_df list

The filter_process object output

As you can see, we get a nice tibble of counts we can use for reporting, and as a kind of ‘sanity check’ of our data

resp_out |> purrr::pluck(1)
# A tibble: 8 × 2
  filter                   count
  <chr>                    <int>
1 Oxygen given as med         97
2 Oxygen therapy procedure   106
3 Pulse oximetry < 90        373
4 911 calls                  293
5 Adults denominator         170
6 Peds denominator            58
7 Initial population         228
8 Total dataset              500

NEMSQA Respiratory-02

What proportion of patients with hypoxia had oxygen administered?

resp_02 <- ems_data |> 
  nemsqar::respiratory_02(erecord_01_col = erecord_01,
                                     incident_date_col = incident_date,
                                     patient_DOB_col = patient_dob,
                                     epatient_15_col = epatient_15,
                                     epatient_16_col = epatient_16,
                                     eresponse_05_col = eresponse_05,
                                     evitals_12_col = evitals_12,
                                     emedications_03_col = emedications_03,
                                     eprocedures_03_col = eprocedures_03,
                                     confidence_interval = TRUE,
                                     method = "wilson",
                                     conf.level = 0.95,
                                     correct = TRUE,
                                     .by = county
                                     )

Respiratory-02 Results

From our sample of three counties in a target state, we can understand EMS performance on this measure.

resp_02 |> dplyr::arrange(county)
# A tibble: 9 × 9
  county measure  pop   numerator denominator  prop prop_label lower_ci upper_ci
  <chr>  <chr>    <chr>     <int>       <int> <dbl> <chr>         <dbl>    <dbl>
1 A      Respira… Adul…        19          56 0.339 33.93%        0.222    0.479
2 A      Respira… Peds          6          18 0.333 33.33%        0.144    0.588
3 A      Respira… All          25          74 0.338 33.78%        0.235    0.458
4 B      Respira… Adul…        18          50 0.36  36%           0.233    0.509
5 B      Respira… Peds          9          20 0.45  45%           0.238    0.680
6 B      Respira… All          27          70 0.386 38.57%        0.274    0.510
7 C      Respira… Adul…        17          64 0.266 26.56%        0.167    0.393
8 C      Respira… Peds         10          20 0.5   50%           0.299    0.701
9 C      Respira… All          27          84 0.321 32.14%        0.226    0.433

Real-world Impact

  • traumar: Risk-adjusted benchmarking + SEQIC indicators for all 120 trauma centers in Iowa
    • Project and examples: https://github.com/bemts-hhs/SEQIC-Report-Distribution-2025
  • nemsqar1: First statewide report on all 21 NEMSQA measures in Iowa
    • Project and examples: https://github.com/bemts-hhs/NEMSQA-Report-2025
  • Reproducible, interpretable, fast

Join us

  • Open source needs more voices!
  • Trauma, EMS, epidemiology - R users welcome!
  • Contributors:
    • Nicolas Foss, Iowa HHS
    • Samuel Kordik, Dallas Fire
    • Alyssa Green, ESO

Thank you!


Questions?

Contact

Nicolas Foss, Ed.D., MS
Epidemiologist
Bureau of Emergency Medical and Trauma Services
Bureau of Health Statistics
Division of Public Health
Iowa Department of Health and Human Services
nicolas.foss at hhs.iowa.gov
515-985-9627
bemts-hhs
bemts-hhs/useR_2025